diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index e6cd74bd1..71c7ea6f0 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -438,6 +438,20 @@ export function onStateRemoved(fn: (key: string) => void): () => void { return () => removedListeners.delete(fn); } +/** 세션 칸을 골라 지움(401 떠남) — 칸마다 지움 알림(설계 가게가 미룬 쓰기를 거둠). */ +export function clearSessionKeys(match: (key: string) => boolean): void { + const doomed: string[] = []; + try { + for (let index = 0; index < window.sessionStorage.length; index += 1) { + const key = window.sessionStorage.key(index); + if (key !== null && match(key)) doomed.push(key); + } + } catch { + return; // 저장소가 막힌 브라우저 — 지울 것이 없다. + } + doomed.forEach((key) => writeRaw(key, null)); +} + function writeRaw(key: string, value: string | null): void { if (value === null) removedListeners.forEach((fn) => fn(key)); try { diff --git a/A00_Common/b_project_switch.ts b/A00_Common/b_project_switch.ts index 9e1df05b5..5e300651d 100644 --- a/A00_Common/b_project_switch.ts +++ b/A00_Common/b_project_switch.ts @@ -11,7 +11,7 @@ * ========================================================================== */ import { CURRENT_PROJECT_ID_KEY } from "../config/config_frontend"; -import { STATE_REGISTRY, type StateEntry } from "./b_page_state"; +import { clearSessionKeys, STATE_REGISTRY, type StateEntry } from "./b_page_state"; /** 이 탭이 마지막으로 들어온 프로젝트. */ const ENTERED_KEY = "aislo:entered-project"; @@ -212,14 +212,10 @@ export async function wipeIfOwnerChanged(user: string | number): Promise { if (!projectId) return; - try { - // 프로젝트 칸 열쇠는 모두 프로젝트 번호(uuid)를 품음 — 등록표 · 옛 키 · 페이지 칸을 한 줄로. - Object.keys(sessionEntries()) - .filter((key) => key.includes(projectId)) - .forEach((key) => window.sessionStorage.removeItem(key)); - } catch { - /* 저장소가 막힌 브라우저 — 지울 것이 없다. */ - } + // 프로젝트 칸 열쇠는 모두 프로젝트 번호(uuid)를 품음 — 등록표 · 옛 키 · 페이지 칸을 한 줄로. + // 등록표 창구로 지움 — 지움 알림을 받은 설계 가게가 미뤄 둔 쓰기(1 s)를 거둠(떠난 뒤 되살아남 막음). + // ponytail: 아직 한 번도 안 쓴 칸의 미룬 쓰기는 못 거둠 — 생기면 가게에 프로젝트 단위 거둠을 더함. + clearSessionKeys((key) => key.includes(projectId)); const { purgeProject } = await import("./b_asset_cache"); await purgeProject(projectId); } diff --git a/A00_Common/main.ts b/A00_Common/main.ts index cd89db516..58e4bbe11 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -28,9 +28,11 @@ import { ensureInitialHash, setLeaveGuard, draftRoute, + isProjectRoute, showWithinPage, takeLeaveGuard, } from "./router"; +import { currentProjectId, leaveProject } from "./b_project_switch"; const FOOTERLESS_ROUTES: readonly RoutePath[] = [ ROUTES.B01_ACCOUNT, @@ -76,6 +78,10 @@ function bootstrap(): void { "is-bm-route", NATIVE_MENU_ROUTE.test(currentRoute()), ); // 전역 글 선택 막음(81-1) + // 프로젝트를 떠남(401) — 떠남 물음을 지난 뒤 · 그 프로젝트 세션 칸 · 3D 보관함 몫 비움 + if (isProjectRoute(shown) && !isProjectRoute(currentRoute())) { + void leaveProject(currentProjectId()); + } const within = showWithinPage(currentRoute()); // B05 ↔ B06 — 보기만 바꿈(24-3a) shown = currentRoute(); shownHash = window.location.hash; diff --git a/resources/tester/helper_401_project_isolation.cjs b/resources/tester/helper_401_project_isolation.cjs index 38da3bbd3..a086d0226 100644 --- a/resources/tester/helper_401_project_isolation.cjs +++ b/resources/tester/helper_401_project_isolation.cjs @@ -74,7 +74,7 @@ function openTab() { currentRoute: () => router.route, setLeaveGuard: (fn) => ((router.guard = fn), (router.sets += 1)), }); - return { win, sw }; + return { win, sw, ps: load("A00_Common/b_page_state.ts") }; } const X = "11111111-aaaa-4aaa-8aaa-000000000001"; @@ -147,8 +147,12 @@ const keys = (win) => Object.keys(win.sessionStorage.dump()).sort(); ]) { shared.idb.data.set(`${pid}|${url}`, { key: `${pid}|${url}`, projectId: pid, url }); } + const removed = []; + const stop = A.ps.onStateRemoved((key) => removed.push(key)); // 설계 가게가 미룬 쓰기를 거두는 알림 await A.sw.leaveProject(X); + stop(); out.leave = { + notified: removed.length, keys: keys(A.win), expected: [...keep].sort(), entered: A.win.sessionStorage.getItem("aislo:entered-project"), diff --git a/resources/tester/test_401_project_isolation.py b/resources/tester/test_401_project_isolation.py index 619496f48..757f14daa 100644 --- a/resources/tester/test_401_project_isolation.py +++ b/resources/tester/test_401_project_isolation.py @@ -52,6 +52,7 @@ def test_떠남은_그_프로젝트_몫만_지움(out): assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본 assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움 assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림 + assert leave["notified"] == 13 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠 def test_떠남_물음_한_번(out): @@ -96,3 +97,12 @@ def test_떠남_취소_뒤_단계_단추_다시_맞춤(): assert "window.dispatchEvent(new Event(ROUTE_RESTORED_EVENT))" in main help_ts = (ROOT / "ui_template/ui_template_help.ts").read_text(encoding="utf-8") assert "window.addEventListener(ROUTE_RESTORED_EVENT, onHash);" in help_ts + + +def test_떠남은_물음_뒤_한_자리에서_비움(): + # 572-4 — hashchange 가드(물음)를 지난 renderAll 첫머리 · 프로젝트 화면 → 밖 일 때만 + main = (ROOT / "A00_Common/main.ts").read_text(encoding="utf-8") + render = main[main.index("const renderAll") : main.index("const within")] + assert "isProjectRoute(shown) && !isProjectRoute(currentRoute())" in render + assert "leaveProject(currentProjectId())" in render + assert main.index("takeLeaveGuard()") > main.index("const renderAll") # 물음은 renderAll 앞에서