[{"data":1,"prerenderedAt":853},["ShallowReactive",2],{"content-\u002Fdocs\u002Frecipes\u002Fmulti-tab-sync":3},{"id":4,"title":5,"body":6,"description":845,"extension":846,"meta":847,"navigation":848,"path":849,"seo":850,"stem":851,"__hash__":852},"docs\u002Fdocs\u002Frecipes\u002Fmulti-tab-sync.md","Multi-tab sync",{"type":7,"value":8,"toc":827},"minimark",[9,13,17,47,61,66,69,72,76,183,186,190,193,196,245,248,252,255,263,274,281,284,307,364,368,371,376,396,399,411,415,418,433,436,476,480,489,492,512,516,519,627,644,648,661,665,668,709,713,721,725,732,738,745,750,778,781,784,790,806,812,823],[10,11,5],"h1",{"id":12},"multi-tab-sync",[14,15,16],"p",{},"A user with multiple open tabs of the same keyed form gets one\nlogical form across all of them. Type in tab A → tab B converges\non the next microtask. No reload, no manual subscription, no\npersistence required.",[18,19,24],"pre",{"className":20,"code":21,"language":22,"meta":23,"style":23},"language-ts shiki shiki-themes github-light github-dark","useForm({ schema, key: 'signup' })\n","ts","",[25,26,27],"code",{"__ignoreMap":23},[28,29,32,36,40,44],"span",{"class":30,"line":31},"line",1,[28,33,35],{"class":34},"sScJk","useForm",[28,37,39],{"class":38},"sVt8B","({ schema, key: ",[28,41,43],{"class":42},"sZZnC","'signup'",[28,45,46],{"class":38}," })\n",[14,48,49,50,53,54,56,57,60],{},"That's the whole opt-in. Anywhere ",[25,51,52],{},"key:"," is set and the runtime\nis in a secure context, same-keyed ",[25,55,35],{}," callsites in\nsame-origin tabs auto-pair over a ",[25,58,59],{},"BroadcastChannel"," and mirror\nevery mutation.",[62,63,65],"h2",{"id":64},"what-it-closes","What it closes",[14,67,68],{},"The user-impact footgun without sync: a user submits in tab A\nwhile tab B holds stale state. Tab B looks live (no error), so\nsubsequent edits there race against and overwrite the\njust-submitted truth. The data-loss mode is invisible to the\nuser.",[14,70,71],{},"With sync on, every same-keyed tab converges in near real-time.\nTab B sees tab A's submit (the cleared form), so further edits\nthere start from a known baseline.",[62,73,75],{"id":74},"what-syncs","What syncs",[77,78,79,92],"table",{},[80,81,82],"thead",{},[83,84,85,89],"tr",{},[86,87,88],"th",{},"Surface",[86,90,91],{},"Sync model",[93,94,95,110,121,131,150,165,175],"tbody",{},[83,96,97,103],{},[98,99,100],"td",{},[25,101,102],{},"form.values",[98,104,105,106,109],{},"Per-mutation ",[25,107,108],{},"Patch[]"," (live); full snapshot on join.",[83,111,112,118],{},[98,113,114,117],{},[25,115,116],{},"blankPaths"," set",[98,119,120],{},"Per-mutation added\u002Fremoved; snapshot on join.",[83,122,123,128],{},[98,124,125],{},[25,126,127],{},"errors",[98,129,130],{},"NOT synced — locally re-derived from value via validation.",[83,132,133,136],{},[98,134,135],{},"Field interaction state",[98,137,138,139,142,143,142,146,149],{},"NOT synced — ",[25,140,141],{},"touched","\u002F",[25,144,145],{},"focused",[25,147,148],{},"blurred"," are UI-state, tab-local.",[83,151,152,155],{},[98,153,154],{},"Submit lifecycle",[98,156,138,157,160,161,164],{},[25,158,159],{},"submitCount"," \u002F ",[25,162,163],{},"submitError"," are per-callsite.",[83,166,167,172],{},[98,168,169],{},[25,170,171],{},"instanceId",[98,173,174],{},"NOT synced — per-mount identity by definition.",[83,176,177,180],{},[98,178,179],{},"History chain",[98,181,182],{},"NOT synced — each tab's undo timeline walks its own user's intent.",[14,184,185],{},"Errors aren't broadcast because they'd carry sensitive context\n(\"invalid SSN: 123-45-6789\"). Each tab re-runs its own validation\nagainst the synced value — one source of truth, zero leaks.",[62,187,189],{"id":188},"conflict-semantics","Conflict semantics",[14,191,192],{},"Last-writer-wins. Two tabs typing into the same field at the\nsame instant produce convergent state on whichever message\narrives later. For form fields (mostly short scalars), the cost\nof an occasional clobbered character is far less than the cost\nof invisible divergence.",[14,194,195],{},"There's no focus-skip rule — the field a user is currently in\nWILL accept remote writes mid-typing. If you need stricter\nsemantics for a particular field, opt it out per-register:",[18,197,201],{"className":198,"code":199,"language":200,"meta":23,"style":23},"language-vue shiki shiki-themes github-light github-dark","\u003Cinput v-register=\"register('notes', { multiTab: false })\" \u002F>\n","vue",[25,202,203],{"__ignoreMap":23},[28,204,205,208,212,215,218,221,224,227,230,233,237,240,242],{"class":30,"line":31},[28,206,207],{"class":38},"\u003C",[28,209,211],{"class":210},"s9eBZ","input",[28,213,214],{"class":34}," v-register",[28,216,217],{"class":38},"=",[28,219,220],{"class":42},"\"",[28,222,223],{"class":34},"register",[28,225,226],{"class":38},"(",[28,228,229],{"class":42},"'notes'",[28,231,232],{"class":38},", { multiTab: ",[28,234,236],{"class":235},"sj4cs","false",[28,238,239],{"class":38}," })",[28,241,220],{"class":42},[28,243,244],{"class":38}," \u002F>\n",[14,246,247],{},"The opted-out field stays tab-local — broadcasts neither out\nnor in for that path, even when the rest of the form syncs.",[62,249,251],{"id":250},"disabling-sync","Disabling sync",[14,253,254],{},"Three levels of opt-out. The cascade goes (most specific wins):",[18,256,261],{"className":257,"code":259,"language":260},[258],"language-text","register(path, { multiTab: false })   ◀── single field tab-local\nuseForm({ multiTab: false })          ◀── whole form tab-isolated\ncreateAttaform({ defaults: { multiTab: false } })  ◀── app-wide\n","text",[25,262,259],{"__ignoreMap":23},[14,264,265,266,269,270,273],{},"The cascade is downgrade-only. ",[25,267,268],{},"multiTab: false"," at any level\nprevents the broadcaster from instantiating; ",[25,271,272],{},"multiTab: true"," at\na more specific level can NOT bring it back if a broader scope\nalready disabled it.",[62,275,277,278],{"id":276},"pairing-with-persist","Pairing with ",[25,279,280],{},"persist:",[14,282,283],{},"Sync and persistence are independent — both, either, or neither.",[285,286,287,295,301],"ul",{},[288,289,290,294],"li",{},[291,292,293],"strong",{},"Sync only",": live cross-tab convergence; no durable\nbaseline. Reloading the tab loses the in-memory state and\nfresh-joins via handshake to any other live tab.",[288,296,297,300],{},[291,298,299],{},"Persist only",": durable baseline; tabs don't see each\nother's mid-edit state.",[288,302,303,306],{},[291,304,305],{},"Both",": sync drives live convergence; persist drives\nwarm-start. Persistence hydration is the floor — when a\nBroadcastChannel snapshot arrives on a fresh mount, it\noverrides the disk-persisted baseline.",[18,308,310],{"className":20,"code":309,"language":22,"meta":23,"style":23},"useForm({\n  schema,\n  key: 'signup',\n  persist: 'local', \u002F\u002F warm-start\n  \u002F\u002F multiTab implicit-true → live cross-tab convergence\n})\n",[25,311,312,319,325,336,352,358],{"__ignoreMap":23},[28,313,314,316],{"class":30,"line":31},[28,315,35],{"class":34},[28,317,318],{"class":38},"({\n",[28,320,322],{"class":30,"line":321},2,[28,323,324],{"class":38},"  schema,\n",[28,326,328,331,333],{"class":30,"line":327},3,[28,329,330],{"class":38},"  key: ",[28,332,43],{"class":42},[28,334,335],{"class":38},",\n",[28,337,339,342,345,348],{"class":30,"line":338},4,[28,340,341],{"class":38},"  persist: ",[28,343,344],{"class":42},"'local'",[28,346,347],{"class":38},", ",[28,349,351],{"class":350},"sJ8bj","\u002F\u002F warm-start\n",[28,353,355],{"class":30,"line":354},5,[28,356,357],{"class":350},"  \u002F\u002F multiTab implicit-true → live cross-tab convergence\n",[28,359,361],{"class":30,"line":360},6,[28,362,363],{"class":38},"})\n",[62,365,367],{"id":366},"security","Security",[14,369,370],{},"This section is required reading for production deployments,\nparticularly regulated-data contexts (PII, PHI, FedRAMP, HIPAA).",[372,373,375],"h3",{"id":374},"secure-context-requirement-https-or-localhost","Secure-context requirement (HTTPS or localhost)",[14,377,378,379,382,383,347,386,347,389,335,392,395],{},"The module activates only when ",[25,380,381],{},"window.isSecureContext === true",",\nwhich the browser defines as HTTPS in production OR localhost in\ndevelopment (covers ",[25,384,385],{},"localhost",[25,387,388],{},"127.0.0.1",[25,390,391],{},"[::1]",[25,393,394],{},"*.localhost","). Plain HTTP on a real hostname silently noops with\na one-shot dev warning.",[14,397,398],{},"Same gate browsers apply to other sensitive APIs (clipboard,\ngeolocation, push, web crypto subtle) — no new mental model.",[14,400,401,404,405,410],{},[291,402,403],{},"Production deployments must be served over HTTPS for sync to\nfunction."," If sync isn't working in prod, check the protocol\nfirst. The same gate fires for built-in persistence storage\nadapters — see ",[406,407,409],"a",{"href":408},".\u002Fpersistence#security-what-not-to-persist","Persistence — Security",".",[372,412,414],{"id":413},"data-flow-audit","Data-flow audit",[14,416,417],{},"What crosses tab boundaries:",[285,419,420,427],{},[288,421,422,423,426],{},"Form values (typed input, programmatic writes, ",[25,424,425],{},"reset()",",\narray helpers).",[288,428,429,430,432],{},"The ",[25,431,116],{}," set (so cleared-but-defaulted numeric fields\nstay empty across tabs).",[14,434,435],{},"What stays tab-local:",[285,437,438,441,451,459,462,469],{},[288,439,440],{},"Errors (re-derived locally on the receiver).",[288,442,443,444,347,446,347,448,450],{},"Field interaction state (",[25,445,141],{},[25,447,145],{},[25,449,148],{},").",[288,452,453,454,347,456,458],{},"Submit lifecycle (",[25,455,159],{},[25,457,163],{},", in-flight\npromise).",[288,460,461],{},"The history chain (undo\u002Fredo).",[288,463,464,465,468],{},"Anything at a path matching ",[25,466,467],{},"sensitiveNames"," (stripped\noutbound AND rejected inbound).",[288,470,471,472,475],{},"Anything at a path marked ",[25,473,474],{},"register('x', { multiTab: false })","\n(symmetric tab-local).",[372,477,479],{"id":478},"threat-model","Threat model",[14,481,482,484,485,488],{},[25,483,59],{}," is ",[291,486,487],{},"same-origin only"," — browser-enforced.\nCross-origin tabs \u002F iframes \u002F windows cannot subscribe. Messages\nare transient (not persisted) — no replay-across-reload surface.",[14,490,491],{},"What sync expands vs. status quo:",[285,493,494,500,506],{},[288,495,496,499],{},[291,497,498],{},"XSS amplification."," An XSS bug in any tab can passively\neavesdrop on or actively inject into every same-origin tab\nrunning the same keyed form. Same-origin trust is binary;\nthis is irreducible at the library layer.",[288,501,502,505],{},[291,503,504],{},"Third-party scripts on the same origin"," (analytics,\nembedded widgets, ad SDKs) can subscribe to channels.",[288,507,508,511],{},[291,509,510],{},"PII \u002F PHI exposure"," widens — previously gated behind a\npersistence opt-in, now flows by default for any keyed form.",[372,513,515],{"id":514},"defenses","Defenses",[14,517,518],{},"Built into v1, not optional:",[285,520,521,566,586,605,615,621],{},[288,522,523,526,527,529,530],{},[291,524,525],{},"Sensitive-path filtering — outbound AND inbound."," Paths\nmatching the resolved ",[25,528,467],{}," list are stripped\nbefore posting AND rejected on receive. Defense in depth —\nthe wire is never trusted, even when the originating tab\n\"should have\" stripped them. The same list gates persistence\nand the DevTools redact walk; extend per-form or globally:",[18,531,533],{"className":20,"code":532,"language":22,"meta":23,"style":23},"createAttaform({\n  defaults: { sensitiveNames: [...DEFAULT_SENSITIVE_NAMES, 'mrn'] },\n})\n",[25,534,535,542,562],{"__ignoreMap":23},[28,536,537,540],{"class":30,"line":31},[28,538,539],{"class":34},"createAttaform",[28,541,318],{"class":38},[28,543,544,547,551,554,556,559],{"class":30,"line":321},[28,545,546],{"class":38},"  defaults: { sensitiveNames: [",[28,548,550],{"class":549},"szBVR","...",[28,552,553],{"class":235},"DEFAULT_SENSITIVE_NAMES",[28,555,347],{"class":38},[28,557,558],{"class":42},"'mrn'",[28,560,561],{"class":38},"] },\n",[28,563,564],{"class":30,"line":327},[28,565,363],{"class":38},[288,567,568,571,572,160,575,160,578,581,582,585],{},[291,569,570],{},"Prototype-pollution defense."," Inbound patches with\n",[25,573,574],{},"__proto__",[25,576,577],{},"constructor",[25,579,580],{},"prototype"," segments in their\npath are rejected before ",[25,583,584],{},"applyPatchesForward"," touches the\nform.",[288,587,588,594,595,597,598,600,601,604],{},[291,589,590,591,410],{},"Echo drop via per-module ",[25,592,593],{},"senderId"," Every outbound\nmessage carries a per-",[25,596,35],{}," UUID; receivers drop messages\nwhose ",[25,599,593],{}," matches their own. Defends intra-tab self-\nloops (two ",[25,602,603],{},"useForm({ key })"," instances in one page) and any\nUA echo behaviour.",[288,606,607,610,611,614],{},[291,608,609],{},"Protocol versioning."," Every message carries ",[25,612,613],{},"v: 1",";\nunknown versions are dropped silently. Lets the wire format\nevolve across rolling deploys without silently corrupting\nolder tabs.",[288,616,617,620],{},[291,618,619],{},"No errors \u002F submit lifecycle on the wire."," An error\nmessage can contain sensitive context (\"invalid SSN: 123-45-\n6789\"). Validation runs locally on the receiver; error maps\nare not synced.",[288,622,623,626],{},[291,624,625],{},"Post-apply schema validate + rollback."," When the pre-apply\nform is valid, the post-apply candidate is schema-validated\ntoo; rollback on throw. Catches cross-field refinement\nviolations a hostile sender could craft.",[628,629,630],"blockquote",{},[14,631,632,635,636,639,640,643],{},[291,633,634],{},"On XSS-style HTML sanitization",": deliberately NOT applied.\nForm values are data, not markup. Sanitization would mangle\nlegit strings like ",[25,637,638],{},"\"O'Brien\""," or ",[25,641,642],{},"\"2 \u003C 3 = true\"",". Same-\norigin trust is binary; an attacker with XSS already controls\nequivalent surfaces (cookies, localStorage, postMessage). The\ndefenses above are strictly stronger (schema-driven,\nlossless).",[372,645,647],{"id":646},"plaintext-on-the-wire","Plaintext on the wire",[14,649,650,651,654,655,658,659,410],{},"Persistence layers that wrap a custom storage adapter with\nencryption (",[25,652,653],{},"persist: { storage: encryptedAdapter }",") still\nship plaintext over the BroadcastChannel. Encrypted-at-rest\nexpectations are ",[291,656,657],{},"not"," preserved across the channel. Forms\nwith that expectation should set ",[25,660,268],{},[372,662,664],{"id":663},"recommended-posture-regulated-data","Recommended posture (regulated data)",[14,666,667],{},"For PII \u002F PHI \u002F FedRAMP \u002F HIPAA contexts:",[285,669,670,678,693,703],{},[288,671,672,677],{},[291,673,674,676],{},[25,675,268],{}," per-form"," for any form holding regulated\ndata. Tab-isolation is the conservative posture.",[288,679,680,685,686,347,689,692],{},[291,681,682,683],{},"Extend ",[25,684,467],{}," with your compliance-specific\nfield names (",[25,687,688],{},"mrn",[25,690,691],{},"tax_id",", etc.). The same list gates\npersistence AND sync AND DevTools.",[288,694,695,698,699,702],{},[291,696,697],{},"Strict CSP"," (",[25,700,701],{},"script-src 'self'"," minimum). Reduces the\nsame-origin attacker surface to scripts you control.",[288,704,705,708],{},[291,706,707],{},"HTTPS only"," in production. The library noops sync on\nplain HTTP — make it loud (audit logs, deployment gates) if\nany environment serves the app over HTTP.",[372,710,712],{"id":711},"iframe-behavior","Iframe behavior",[14,714,715,716,718,719,410],{},"Same-origin iframes embedded on the page share the channel —\nthey receive broadcasts from the parent's keyed form. This is\nby design; iframe-embedded forms commonly want the same\nidentity as the parent. For isolation, use cross-origin\niframes (browser-enforced channel isolation) or pass\n",[25,717,268],{}," to the iframe's ",[25,720,35],{},[62,722,724],{"id":723},"how-it-works-mechanism","How it works (mechanism)",[14,726,727,728,731],{},"The channel name derives from ",[25,729,730],{},"form.key"," + the schema's\nstructural fingerprint:",[18,733,736],{"className":734,"code":735,"language":260},[258],"attaform:sync:${formKey}:${hashStableString(schema.fingerprint())}\n",[25,737,735],{"__ignoreMap":23},[14,739,740,741,744],{},"Same ",[25,742,743],{},"key"," + same schema → same channel name → tabs auto-pair.\nDifferent schemas at the same key would collide otherwise, so\nthe fingerprint disambiguates.",[14,746,747],{},[291,748,749],{},"Mount-time handshake (leader-election):",[751,752,753,759,766,772],"ol",{},[288,754,755,756,410],{},"Joining tab posts ",[25,757,758],{},"{ kind: 'hello', senderId }",[288,760,761,762,765],{},"Established tabs respond ",[25,763,764],{},"{ kind: 'announce', senderId }","\n(UUID only — cheap).",[288,767,768,769,771],{},"Joining tab collects announces for ~50ms, sorts the roster,\npicks lowest ",[25,770,593],{}," as leader.",[288,773,755,774,777],{},[25,775,776],{},"{ kind: 'requestSnapshot', targetId: leader }",". Only the leader responds with a full snapshot.",[14,779,780],{},"Bandwidth on an N-tab join is N tiny announces + 1 snapshot,\nregardless of N — vs the naive \"everyone responds with a\nsnapshot\" which would be O(N) full snapshots.",[14,782,783],{},"If no announces arrive (solo tab), the joining tab transitions\nto established and proceeds with hydrated \u002F default state.",[14,785,786,787,789],{},"If the elected leader doesn't reply within ~200ms, the joining\ntab retries with the next-lowest ",[25,788,593],{},". Three attempts max\nbefore falling back to solo.",[14,791,792,795,796,799,800,802,803,410],{},[291,793,794],{},"Steady state:"," every local mutation diffs against a per-\nmodule prior snapshot and posts ",[25,797,798],{},"{ kind: 'patches', formPatches, blankPathsAdded, blankPathsRemoved }",". Receivers apply via\n",[25,801,584],{}," + ",[25,804,805],{},"state.applyFormReplacement(form, { crossTab: true, persist: false })",[14,807,429,808,811],{},[25,809,810],{},"crossTab: true"," meta flag signals to:",[285,813,814,817,820],{},[288,815,816],{},"The outbound broadcaster: skip (this write came FROM a\nsibling).",[288,818,819],{},"The history module: update the diff anchor but don't push a\ndelta (remote writes aren't part of the local user's undo\ntimeline).",[288,821,822],{},"The persistence writer: skip (the originating tab already\npersisted to its own storage; double-write is wasteful).",[824,825,826],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":23,"searchDepth":321,"depth":321,"links":828},[829,830,831,832,833,835,844],{"id":64,"depth":321,"text":65},{"id":74,"depth":321,"text":75},{"id":188,"depth":321,"text":189},{"id":250,"depth":321,"text":251},{"id":276,"depth":321,"text":834},"Pairing with persist:",{"id":366,"depth":321,"text":367,"children":836},[837,838,839,840,841,842,843],{"id":374,"depth":327,"text":375},{"id":413,"depth":327,"text":414},{"id":478,"depth":327,"text":479},{"id":514,"depth":327,"text":515},{"id":646,"depth":327,"text":647},{"id":663,"depth":327,"text":664},{"id":711,"depth":327,"text":712},{"id":723,"depth":321,"text":724},"Same-keyed forms in same-origin tabs auto-pair via BroadcastChannel — every keystroke mirrors across tabs in near real-time, with sensitive paths filtered both directions and HTTPS-or-localhost required.","md",{},true,"\u002Fdocs\u002Frecipes\u002Fmulti-tab-sync",{"title":5,"description":845},"docs\u002Frecipes\u002Fmulti-tab-sync","xb_7M0n2GKRfkwpQgmUWcDSifAIXDOmSsUlEpSXr-kk",1778695669225]