@multiplatform.one/theme 7.34.4 → 7.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +20 -5
- package/src/audit/constraintAudit.spec.ts +274 -87
- package/src/audit/constraintAudit.ts +135 -120
- package/src/audit/constraintScope.spec.ts +1 -1
- package/src/audit/designTokens.ts +42 -0
- package/src/audit/themeMatrix.spec.ts +83 -2
- package/src/audit/themeMatrix.ts +33 -1
- package/src/figma/figmaTokens.spec.ts +26 -34
- package/src/figma/figmaTokens.ts +26 -50
- package/src/fonts/createFontLoader.ts +4 -1
- package/src/menuRow.ts +4 -1
- package/src/theme/FontKnobStyles.tsx +2 -1
- package/src/theme/Preset.stories.tsx +0 -1
- package/src/theme/Preset.tsx +6 -1
- package/src/theme/PresetContext.ts +4 -0
- package/src/theme/Surface.spec.tsx +114 -67
- package/src/theme/Surface.stories.tsx +8 -25
- package/src/theme/Surface.tsx +87 -43
- package/src/theme/accentFloor.spec.ts +37 -11
- package/src/theme/activateThemeConfig.ts +22 -6
- package/src/theme/animations/animations.spec.ts +62 -0
- package/src/theme/animations/css.ts +9 -35
- package/src/theme/animations/reactNative.ts +98 -25
- package/src/theme/boundaryFloor.spec.ts +57 -0
- package/src/theme/chartPalette.spec.ts +56 -27
- package/src/theme/chartPalette.ts +33 -28
- package/src/theme/colorRules.spec.ts +32 -72
- package/src/theme/cookies.spec.ts +49 -18
- package/src/theme/cookies.ts +36 -7
- package/src/theme/cookiesPersistence.spec.ts +5 -5
- package/src/theme/createDefaultThemeConfig.spec.ts +182 -20
- package/src/theme/createDefaultThemeConfig.ts +34 -28
- package/src/theme/createThemes.ts +169 -218
- package/src/theme/defaults/accent.ts +3 -35
- package/src/theme/defaults/base.ts +14 -53
- package/src/theme/defaults/builderOptions.ts +68 -85
- package/src/theme/defaults/categoryFonts.ts +10 -0
- package/src/theme/defaults/fonts.spec.ts +84 -4
- package/src/theme/defaults/fonts.ts +166 -124
- package/src/theme/defaults/house.ts +48 -0
- package/src/theme/defaults/index.ts +1 -0
- package/src/theme/defaults/neutralRamp.spec.ts +52 -47
- package/src/theme/devtools/ColorLineVisualizer.tsx +6 -6
- package/src/theme/devtools/ThemeDevtoolsPanel.setMarkers.spec.tsx +13 -6
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +14 -77
- package/src/theme/devtools/animationOptions.spec.ts +23 -37
- package/src/theme/devtools/animationOptions.ts +4 -6
- package/src/theme/devtools/buildSnapshot.ts +0 -3
- package/src/theme/devtools/knobIcons.tsx +4 -24
- package/src/theme/focusState.spec.ts +9 -30
- package/src/theme/index.ts +17 -0
- package/src/theme/intentFill.spec.ts +42 -0
- package/src/theme/knobs.ts +14 -42
- package/src/theme/layoutTokens.ts +15 -1
- package/src/theme/layoutTokensHooks.spec.tsx +8 -4
- package/src/theme/mergedTokens.ts +20 -0
- package/src/theme/motion.spec.ts +84 -0
- package/src/theme/motion.ts +53 -0
- package/src/theme/motionStops.ts +17 -0
- package/src/theme/motionTiming.ts +59 -0
- package/src/theme/mpo13Proof.spec.ts +43 -46
- package/src/theme/mpo19Proof.spec.ts +13 -14
- package/src/theme/namedInkFloor.spec.ts +60 -0
- package/src/theme/overlaySurfaces.tsx +330 -0
- package/src/theme/preset.types.ts +16 -0
- package/src/theme/presetPreview.ts +32 -0
- package/src/theme/presetRoot.spec.tsx +89 -0
- package/src/theme/presetStore.persistence.spec.ts +141 -0
- package/src/theme/presetStore.spec.ts +205 -0
- package/src/theme/presetStore.ts +286 -0
- package/src/theme/presetUserSync.spec.tsx +168 -0
- package/src/theme/presetUserSync.ts +47 -0
- package/src/theme/presets.spec.ts +22 -15
- package/src/theme/presets.ts +22 -10
- package/src/theme/radiusClass.spec.ts +60 -41
- package/src/theme/radiusClass.ts +17 -4
- package/src/theme/radiusClassTable.generated.ts +3 -3
- package/src/theme/readableColor.spec.ts +59 -152
- package/src/theme/readableColor.ts +34 -174
- package/src/theme/recipeInputs.ts +153 -295
- package/src/theme/recipes.ts +58 -18
- package/src/theme/resolveKnobs.spec.ts +528 -588
- package/src/theme/resolveKnobs.ts +250 -325
- package/src/theme/scale/ScaleStyles.native.tsx +19 -0
- package/src/theme/scale/ScaleStyles.tsx +48 -0
- package/src/theme/scale/activeScale.ts +30 -0
- package/src/theme/scale/deriveRecipeInputs.ts +110 -0
- package/src/theme/scale/generateScale.spec.ts +260 -0
- package/src/theme/scale/generateScale.ts +147 -0
- package/src/theme/scale/index.ts +54 -0
- package/src/theme/scale/installScaleConfig.native.spec.ts +77 -0
- package/src/theme/scale/installScaleConfig.native.ts +57 -0
- package/src/theme/scale/installScaleConfig.ts +7 -0
- package/src/theme/scale/scaleAttribute.ts +21 -0
- package/src/theme/scale/scaleCss.probe.spec.tsx +64 -0
- package/src/theme/scale/scaleCss.spec.ts +132 -0
- package/src/theme/scale/scaleCss.ts +95 -0
- package/src/theme/scale/scaleFonts.ts +106 -0
- package/src/theme/scale/scaleInputs.ts +322 -0
- package/src/theme/scale/themeScales.ts +181 -0
- package/src/theme/scale/useActiveScale.spec.tsx +129 -0
- package/src/theme/scale/useActiveScale.ts +95 -0
- package/src/theme/schemeStore.spec.ts +81 -0
- package/src/theme/schemeStore.ts +67 -0
- package/src/theme/shared.spec.ts +7 -6
- package/src/theme/shared.tsx +37 -3
- package/src/theme/sizeLadder.spec.ts +37 -36
- package/src/theme/sizeRecipes.spec.ts +179 -85
- package/src/theme/sizeRecipes.ts +48 -25
- package/src/theme/subsetThemes.spec.ts +9 -3
- package/src/theme/switchThumb.spec.ts +69 -0
- package/src/theme/syntaxFloor.spec.ts +11 -8
- package/src/theme/theme.hydration.native.spec.tsx +51 -0
- package/src/theme/theme.native.tsx +59 -21
- package/src/theme/theme.palette.spec.tsx +76 -0
- package/src/theme/theme.preset.native.spec.tsx +73 -0
- package/src/theme/theme.provider.spec.ts +143 -1
- package/src/theme/theme.tsx +61 -25
- package/src/theme/tileSizes.ts +6 -7
- package/src/theme/transitionProps.native.ts +25 -2
- package/src/theme/transitionProps.spec.ts +34 -0
- package/src/theme/transitionProps.ts +26 -2
- package/src/theme/usePresetSwitch.native.spec.tsx +68 -0
- package/src/theme/usePresetSwitch.native.ts +32 -0
- package/src/theme/usePresetSwitch.spec.tsx +103 -0
- package/src/theme/usePresetSwitch.ts +54 -0
- package/src/theme/useResolvedKnobs.hydration.spec.tsx +2 -2
- package/src/theme/useResolvedKnobs.ts +23 -90
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +25 -30
- package/src/theme/useSchemePreference.native.ts +33 -0
- package/src/theme/useSchemePreference.spec.tsx +51 -0
- package/src/theme/useSchemePreference.ts +32 -0
- package/src/theme/useSchemePreference.web.spec.tsx +32 -0
- package/src/theme/useTheme.native.spec.tsx +68 -0
- package/src/theme/useTheme.native.ts +49 -24
- package/src/theme/useTheme.ts +7 -4
- package/types/audit/constraintAudit.d.ts +6 -5
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/designTokens.d.ts +13 -0
- package/types/audit/designTokens.d.ts.map +1 -0
- package/types/audit/themeMatrix.d.ts +10 -0
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/figma/figmaTokens.d.ts +11 -28
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/fonts/createFontLoader.d.ts.map +1 -1
- package/types/menuRow.d.ts +4 -1
- package/types/menuRow.d.ts.map +1 -1
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/Preset.d.ts.map +1 -1
- package/types/theme/PresetContext.d.ts +4 -0
- package/types/theme/PresetContext.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +57 -26
- package/types/theme/Surface.d.ts.map +1 -1
- package/types/theme/activateThemeConfig.d.ts +5 -3
- package/types/theme/activateThemeConfig.d.ts.map +1 -1
- package/types/theme/animations/css.d.ts +27 -34
- package/types/theme/animations/css.d.ts.map +1 -1
- package/types/theme/animations/reactNative.d.ts +196 -45
- package/types/theme/animations/reactNative.d.ts.map +1 -1
- package/types/theme/chartPalette.d.ts +11 -3
- package/types/theme/chartPalette.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +1 -1
- package/types/theme/cookies.d.ts +12 -1
- package/types/theme/cookies.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +13 -13
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts +58 -2
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/defaults/accent.d.ts.map +1 -1
- package/types/theme/defaults/base.d.ts.map +1 -1
- package/types/theme/defaults/builderOptions.d.ts.map +1 -1
- package/types/theme/defaults/categoryFonts.d.ts +10 -0
- package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +330 -30
- package/types/theme/defaults/fonts.d.ts.map +1 -1
- package/types/theme/defaults/house.d.ts +8 -0
- package/types/theme/defaults/house.d.ts.map +1 -0
- package/types/theme/defaults/index.d.ts +1 -0
- package/types/theme/defaults/index.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +3 -3
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/devtools/animationOptions.d.ts.map +1 -1
- package/types/theme/devtools/buildSnapshot.d.ts.map +1 -1
- package/types/theme/devtools/knobIcons.d.ts +0 -3
- package/types/theme/devtools/knobIcons.d.ts.map +1 -1
- package/types/theme/index.d.ts +11 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +8 -36
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +13 -1
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/mergedTokens.d.ts +11 -0
- package/types/theme/mergedTokens.d.ts.map +1 -0
- package/types/theme/motion.d.ts +17 -0
- package/types/theme/motion.d.ts.map +1 -0
- package/types/theme/motionStops.d.ts +3 -0
- package/types/theme/motionStops.d.ts.map +1 -0
- package/types/theme/motionTiming.d.ts +36 -0
- package/types/theme/motionTiming.d.ts.map +1 -0
- package/types/theme/overlaySurfaces.d.ts +70 -0
- package/types/theme/overlaySurfaces.d.ts.map +1 -0
- package/types/theme/preset.types.d.ts +16 -0
- package/types/theme/preset.types.d.ts.map +1 -1
- package/types/theme/presetPreview.d.ts +14 -0
- package/types/theme/presetPreview.d.ts.map +1 -0
- package/types/theme/presetStore.d.ts +40 -0
- package/types/theme/presetStore.d.ts.map +1 -0
- package/types/theme/presetUserSync.d.ts +7 -0
- package/types/theme/presetUserSync.d.ts.map +1 -0
- package/types/theme/presets.d.ts +12 -4
- package/types/theme/presets.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +5 -2
- package/types/theme/radiusClass.d.ts.map +1 -1
- package/types/theme/radiusClassTable.generated.d.ts +3 -3
- package/types/theme/readableColor.d.ts +11 -49
- package/types/theme/readableColor.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +39 -70
- package/types/theme/recipeInputs.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +79 -18
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +64 -32
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/scale/ScaleStyles.d.ts +18 -0
- package/types/theme/scale/ScaleStyles.d.ts.map +1 -0
- package/types/theme/scale/ScaleStyles.native.d.ts +10 -0
- package/types/theme/scale/ScaleStyles.native.d.ts.map +1 -0
- package/types/theme/scale/activeScale.d.ts +13 -0
- package/types/theme/scale/activeScale.d.ts.map +1 -0
- package/types/theme/scale/deriveRecipeInputs.d.ts +10 -0
- package/types/theme/scale/deriveRecipeInputs.d.ts.map +1 -0
- package/types/theme/scale/generateScale.d.ts +38 -0
- package/types/theme/scale/generateScale.d.ts.map +1 -0
- package/types/theme/scale/index.d.ts +17 -0
- package/types/theme/scale/index.d.ts.map +1 -0
- package/types/theme/scale/installScaleConfig.d.ts +4 -0
- package/types/theme/scale/installScaleConfig.d.ts.map +1 -0
- package/types/theme/scale/installScaleConfig.native.d.ts +13 -0
- package/types/theme/scale/installScaleConfig.native.d.ts.map +1 -0
- package/types/theme/scale/scaleAttribute.d.ts +14 -0
- package/types/theme/scale/scaleAttribute.d.ts.map +1 -0
- package/types/theme/scale/scaleCss.d.ts +25 -0
- package/types/theme/scale/scaleCss.d.ts.map +1 -0
- package/types/theme/scale/scaleFonts.d.ts +16 -0
- package/types/theme/scale/scaleFonts.d.ts.map +1 -0
- package/types/theme/scale/scaleInputs.d.ts +66 -0
- package/types/theme/scale/scaleInputs.d.ts.map +1 -0
- package/types/theme/scale/themeScales.d.ts +52 -0
- package/types/theme/scale/themeScales.d.ts.map +1 -0
- package/types/theme/scale/useActiveScale.d.ts +31 -0
- package/types/theme/scale/useActiveScale.d.ts.map +1 -0
- package/types/theme/schemeStore.d.ts +6 -0
- package/types/theme/schemeStore.d.ts.map +1 -0
- package/types/theme/shared.d.ts +18 -0
- package/types/theme/shared.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +20 -15
- package/types/theme/sizeRecipes.d.ts.map +1 -1
- package/types/theme/theme.d.ts +2 -1
- package/types/theme/theme.d.ts.map +1 -1
- package/types/theme/theme.native.d.ts +10 -1
- package/types/theme/theme.native.d.ts.map +1 -1
- package/types/theme/tileSizes.d.ts +6 -7
- package/types/theme/tileSizes.d.ts.map +1 -1
- package/types/theme/transitionProps.d.ts +3 -1
- package/types/theme/transitionProps.d.ts.map +1 -1
- package/types/theme/transitionProps.native.d.ts +3 -1
- package/types/theme/transitionProps.native.d.ts.map +1 -1
- package/types/theme/usePresetSwitch.d.ts +10 -0
- package/types/theme/usePresetSwitch.d.ts.map +1 -0
- package/types/theme/usePresetSwitch.native.d.ts +10 -0
- package/types/theme/usePresetSwitch.native.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +13 -13
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/types/theme/useSchemePreference.d.ts +8 -0
- package/types/theme/useSchemePreference.d.ts.map +1 -0
- package/types/theme/useSchemePreference.native.d.ts +8 -0
- package/types/theme/useSchemePreference.native.d.ts.map +1 -0
- package/types/theme/useTheme.d.ts +4 -2
- package/types/theme/useTheme.d.ts.map +1 -1
- package/types/theme/useTheme.native.d.ts +10 -1
- package/types/theme/useTheme.native.d.ts.map +1 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { storage } from "@multiplatform.one/store";
|
|
3
|
+
import {
|
|
4
|
+
getPresetChoices,
|
|
5
|
+
loadPresetChoices,
|
|
6
|
+
presetChoiceFor,
|
|
7
|
+
presetChoicesKey,
|
|
8
|
+
recordPresetChoice,
|
|
9
|
+
resetPresetChoicesForTests,
|
|
10
|
+
setPresetOwner,
|
|
11
|
+
} from "./presetStore";
|
|
12
|
+
|
|
13
|
+
const { items } = vi.hoisted(() => ({ items: new Map<string, string>() }));
|
|
14
|
+
vi.mock("@multiplatform.one/store", () => ({
|
|
15
|
+
storage: {
|
|
16
|
+
getItem: vi.fn((key: string) => items.get(key) ?? null),
|
|
17
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
18
|
+
items.set(key, value);
|
|
19
|
+
}),
|
|
20
|
+
removeItem: vi.fn((key: string) => {
|
|
21
|
+
items.delete(key);
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.mocked(storage.getItem)
|
|
28
|
+
.mockReset()
|
|
29
|
+
.mockImplementation((key) => items.get(key) ?? null);
|
|
30
|
+
vi.mocked(storage.setItem)
|
|
31
|
+
.mockReset()
|
|
32
|
+
.mockImplementation((key, value) => {
|
|
33
|
+
items.set(key, value);
|
|
34
|
+
});
|
|
35
|
+
items.clear();
|
|
36
|
+
resetPresetChoicesForTests();
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => vi.useRealTimers());
|
|
39
|
+
|
|
40
|
+
describe("per-user preset choices", () => {
|
|
41
|
+
it("round-trips the device, user map and owner through one storage key", async () => {
|
|
42
|
+
expect(getPresetChoices()).toEqual({ users: {} });
|
|
43
|
+
await loadPresetChoices();
|
|
44
|
+
recordPresetChoice("roomy");
|
|
45
|
+
recordPresetChoice("dense", "a");
|
|
46
|
+
const expected = recordPresetChoice("ocean", "b");
|
|
47
|
+
await vi.waitFor(() => expect(items.get(presetChoicesKey)).toBe(JSON.stringify(expected)));
|
|
48
|
+
expect([...items.keys()]).toEqual([presetChoicesKey]);
|
|
49
|
+
resetPresetChoicesForTests();
|
|
50
|
+
await expect(loadPresetChoices()).resolves.toEqual(expected);
|
|
51
|
+
expect(presetChoiceFor()).toBe("roomy");
|
|
52
|
+
expect(presetChoiceFor(null)).toBe("roomy");
|
|
53
|
+
expect(presetChoiceFor("a")).toBe("dense");
|
|
54
|
+
expect(presetChoiceFor("b")).toBe("ocean");
|
|
55
|
+
expect(presetChoiceFor("missing")).toBeUndefined();
|
|
56
|
+
expect(presetChoiceFor("toString")).toBeUndefined();
|
|
57
|
+
expect(getPresetChoices().owner).toBe("b");
|
|
58
|
+
expect(setPresetOwner("a").owner).toBe("a");
|
|
59
|
+
expect(setPresetOwner(null).owner).toBeUndefined();
|
|
60
|
+
await vi.waitFor(() =>
|
|
61
|
+
expect(JSON.parse(items.get(presetChoicesKey)!)).not.toHaveProperty("owner"),
|
|
62
|
+
);
|
|
63
|
+
expect(recordPresetChoice("studio", null)).toEqual({
|
|
64
|
+
device: "studio",
|
|
65
|
+
users: { a: "dense", b: "ocean" },
|
|
66
|
+
owner: undefined,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reads only once and returns the current cache on subsequent loads", async () => {
|
|
71
|
+
items.set(presetChoicesKey, JSON.stringify({ users: { a: "dense" } }));
|
|
72
|
+
const first = loadPresetChoices();
|
|
73
|
+
expect(getPresetChoices().users.a).toBe("dense");
|
|
74
|
+
await first;
|
|
75
|
+
recordPresetChoice("roomy", "a");
|
|
76
|
+
await expect(loadPresetChoices()).resolves.toBe(getPresetChoices());
|
|
77
|
+
expect(storage.getItem).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it.each([
|
|
81
|
+
"{bad",
|
|
82
|
+
"null",
|
|
83
|
+
"[]",
|
|
84
|
+
'"dense"',
|
|
85
|
+
'{"users":null}',
|
|
86
|
+
'{"device":12,"owner":[],"users":{"a":42}}',
|
|
87
|
+
])("ignores corrupt storage: %s", async (raw) => {
|
|
88
|
+
items.set(presetChoicesKey, raw);
|
|
89
|
+
await expect(loadPresetChoices()).resolves.toEqual({ users: {} });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("survives throwing reads and writes", async () => {
|
|
93
|
+
vi.mocked(storage.getItem).mockImplementation(() => {
|
|
94
|
+
throw new Error("unavailable");
|
|
95
|
+
});
|
|
96
|
+
vi.mocked(storage.setItem).mockImplementation(() => {
|
|
97
|
+
throw new Error("unavailable");
|
|
98
|
+
});
|
|
99
|
+
await expect(loadPresetChoices()).resolves.toEqual({ users: {} });
|
|
100
|
+
expect(() => recordPresetChoice("dense", "a")).not.toThrow();
|
|
101
|
+
expect(() => setPresetOwner()).not.toThrow();
|
|
102
|
+
expect(presetChoiceFor("a")).toBe("dense");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("loads an async backend and shares its pending read", async () => {
|
|
106
|
+
const saved = { device: "paper", users: { a: "dense" }, owner: "a" };
|
|
107
|
+
vi.mocked(storage.getItem).mockResolvedValue(JSON.stringify(saved));
|
|
108
|
+
const first = loadPresetChoices();
|
|
109
|
+
expect(loadPresetChoices()).toBe(first);
|
|
110
|
+
await expect(first).resolves.toEqual(saved);
|
|
111
|
+
expect(storage.getItem).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("ignores rejected reads and writes", async () => {
|
|
115
|
+
vi.mocked(storage.getItem).mockRejectedValue(new Error("unavailable"));
|
|
116
|
+
vi.mocked(storage.setItem).mockRejectedValue(new Error("unavailable"));
|
|
117
|
+
await expect(loadPresetChoices()).resolves.toEqual({ users: {} });
|
|
118
|
+
recordPresetChoice("dense", "a");
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
expect(presetChoiceFor("a")).toBe("dense");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("times out a backend that never answers and ignores a late answer", async () => {
|
|
124
|
+
vi.useFakeTimers();
|
|
125
|
+
let finish!: (value: string) => void;
|
|
126
|
+
vi.mocked(storage.getItem).mockReturnValue(
|
|
127
|
+
new Promise((resolve) => {
|
|
128
|
+
finish = resolve;
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const pending = loadPresetChoices();
|
|
132
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
133
|
+
await expect(pending).resolves.toEqual({ users: {} });
|
|
134
|
+
finish(JSON.stringify({ users: { a: "dense" } }));
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
expect(getPresetChoices()).toEqual({ users: {} });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("preserves choices made during hydration along with other stored users", async () => {
|
|
140
|
+
let finish!: (value: string) => void;
|
|
141
|
+
vi.mocked(storage.getItem).mockReturnValue(
|
|
142
|
+
new Promise((resolve) => {
|
|
143
|
+
finish = resolve;
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
const pending = loadPresetChoices();
|
|
147
|
+
recordPresetChoice("roomy", "a");
|
|
148
|
+
setPresetOwner();
|
|
149
|
+
finish(JSON.stringify({ device: "paper", users: { a: "dense", b: "ocean" }, owner: "b" }));
|
|
150
|
+
await pending;
|
|
151
|
+
expect(getPresetChoices()).toEqual({
|
|
152
|
+
device: "paper",
|
|
153
|
+
users: { a: "roomy", b: "ocean" },
|
|
154
|
+
owner: undefined,
|
|
155
|
+
});
|
|
156
|
+
expect(JSON.parse(items.get(presetChoicesKey)!)).toEqual({
|
|
157
|
+
device: "paper",
|
|
158
|
+
users: { a: "roomy", b: "ocean" },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each(["choice", "owner"])(
|
|
163
|
+
"keeps saved choices intact until an async read finishes after a %s change",
|
|
164
|
+
async (change) => {
|
|
165
|
+
const saved = { device: "paper", users: { b: "ocean" } };
|
|
166
|
+
items.set(presetChoicesKey, JSON.stringify(saved));
|
|
167
|
+
let finish!: () => void;
|
|
168
|
+
vi.mocked(storage.getItem).mockImplementation(
|
|
169
|
+
(key) =>
|
|
170
|
+
new Promise((resolve) => {
|
|
171
|
+
finish = () => resolve(items.get(key) ?? null);
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
if (change === "choice") recordPresetChoice("roomy", "a");
|
|
175
|
+
else setPresetOwner("a");
|
|
176
|
+
const pending = loadPresetChoices();
|
|
177
|
+
expect(JSON.parse(items.get(presetChoicesKey)!)).toEqual(saved);
|
|
178
|
+
finish();
|
|
179
|
+
await pending;
|
|
180
|
+
const expected = {
|
|
181
|
+
...saved,
|
|
182
|
+
users: change === "choice" ? { b: "ocean", a: "roomy" } : saved.users,
|
|
183
|
+
owner: "a",
|
|
184
|
+
};
|
|
185
|
+
expect(getPresetChoices()).toEqual(expected);
|
|
186
|
+
expect(JSON.parse(items.get(presetChoicesKey)!)).toEqual(expected);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
it("does not let a reset pending load overwrite a fresh cache", async () => {
|
|
191
|
+
let finish!: (value: string) => void;
|
|
192
|
+
vi.mocked(storage.getItem).mockReturnValueOnce(
|
|
193
|
+
new Promise((resolve) => {
|
|
194
|
+
finish = resolve;
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
const old = loadPresetChoices();
|
|
198
|
+
resetPresetChoicesForTests();
|
|
199
|
+
await loadPresetChoices();
|
|
200
|
+
recordPresetChoice("studio");
|
|
201
|
+
finish(JSON.stringify({ device: "paper", users: {} }));
|
|
202
|
+
await old;
|
|
203
|
+
expect(presetChoiceFor()).toBe("studio");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { isWeb } from "@multiplatform.one/platform";
|
|
2
|
+
import { storage } from "@multiplatform.one/store";
|
|
3
|
+
import { cookiePreset, sanitizeOverrides } from "./cookies";
|
|
4
|
+
import type { Knobs } from "./knobs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The persisted preset for platforms without cookies (native): the active
|
|
8
|
+
* preset name and its knob overrides, in `@multiplatform.one/store` storage
|
|
9
|
+
* (MMKV, else AsyncStorage) under the same `mp.preset` key the web cookie
|
|
10
|
+
* uses, plus `mp.ov` for the overrides JSON. One shared store, so every
|
|
11
|
+
* `useTheme()` and the ThemeProvider read the same state.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const storedOverridesKey = "mp.ov";
|
|
15
|
+
|
|
16
|
+
export interface StoredPresetState {
|
|
17
|
+
presetName?: string;
|
|
18
|
+
overrides: Partial<Knobs>;
|
|
19
|
+
/** False until storage has been read; providers hold first render until then. */
|
|
20
|
+
hydrated: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Give up waiting on an async backend that never answers. */
|
|
24
|
+
const HYDRATE_TIMEOUT_MS = 1000;
|
|
25
|
+
|
|
26
|
+
const serverState: StoredPresetState = { overrides: {}, hydrated: true };
|
|
27
|
+
let state: StoredPresetState = { overrides: {}, hydrated: false };
|
|
28
|
+
let writes: Promise<void> = Promise.resolve();
|
|
29
|
+
const isServer = () => isWeb && typeof window === "undefined";
|
|
30
|
+
let hydration: Promise<void> | undefined;
|
|
31
|
+
let written = false;
|
|
32
|
+
const listeners = new Set<() => void>();
|
|
33
|
+
|
|
34
|
+
function emit(next: StoredPresetState): void {
|
|
35
|
+
state = next;
|
|
36
|
+
for (const listener of listeners) listener();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseOverrides(raw: unknown): Partial<Knobs> {
|
|
40
|
+
if (typeof raw !== "string" || raw.length === 0) return {};
|
|
41
|
+
try {
|
|
42
|
+
return sanitizeOverrides(JSON.parse(raw));
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function adopt(rawPreset: unknown, rawOverrides: unknown): void {
|
|
49
|
+
if (written) {
|
|
50
|
+
if (!state.hydrated) emit({ ...state, hydrated: true });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
emit({
|
|
54
|
+
presetName: typeof rawPreset === "string" && rawPreset.length > 0 ? rawPreset : undefined,
|
|
55
|
+
overrides: parseOverrides(rawOverrides),
|
|
56
|
+
hydrated: true,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
61
|
+
return (
|
|
62
|
+
typeof value === "object" &&
|
|
63
|
+
value !== null &&
|
|
64
|
+
typeof (value as PromiseLike<unknown>).then === "function"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read storage once. Synchronous backends (MMKV) hydrate before this returns. */
|
|
69
|
+
export function hydrateStoredPreset(): Promise<void> {
|
|
70
|
+
if (isServer()) return Promise.resolve();
|
|
71
|
+
if (hydration) return hydration;
|
|
72
|
+
let rawPreset: unknown;
|
|
73
|
+
let rawOverrides: unknown;
|
|
74
|
+
try {
|
|
75
|
+
rawPreset = storage.getItem(cookiePreset);
|
|
76
|
+
rawOverrides = storage.getItem(storedOverridesKey);
|
|
77
|
+
} catch {
|
|
78
|
+
adopt(undefined, undefined);
|
|
79
|
+
hydration = Promise.resolve();
|
|
80
|
+
return hydration;
|
|
81
|
+
}
|
|
82
|
+
if (!isThenable(rawPreset) && !isThenable(rawOverrides)) {
|
|
83
|
+
adopt(rawPreset, rawOverrides);
|
|
84
|
+
hydration = Promise.resolve();
|
|
85
|
+
return hydration;
|
|
86
|
+
}
|
|
87
|
+
const read = Promise.all([rawPreset, rawOverrides]).then(([preset, overrides]) =>
|
|
88
|
+
adopt(preset, overrides),
|
|
89
|
+
);
|
|
90
|
+
const timeout = new Promise<void>((resolve) => setTimeout(resolve, HYDRATE_TIMEOUT_MS));
|
|
91
|
+
hydration = Promise.race([read, timeout])
|
|
92
|
+
.catch(() => undefined)
|
|
93
|
+
.then(() => {
|
|
94
|
+
if (!state.hydrated) emit({ ...state, hydrated: true });
|
|
95
|
+
});
|
|
96
|
+
return hydration;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getStoredPreset(): StoredPresetState {
|
|
100
|
+
if (isServer()) return serverState;
|
|
101
|
+
if (!hydration) void hydrateStoredPreset();
|
|
102
|
+
return state;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function subscribeStoredPreset(listener: () => void): () => void {
|
|
106
|
+
if (isServer()) return () => {};
|
|
107
|
+
if (!hydration) void hydrateStoredPreset();
|
|
108
|
+
listeners.add(listener);
|
|
109
|
+
return () => {
|
|
110
|
+
listeners.delete(listener);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Chain every storage write behind the last one, so a slow older write can
|
|
116
|
+
* never land after a newer one and win on relaunch. A failed write is
|
|
117
|
+
* dropped; the in-memory state still drives this session.
|
|
118
|
+
*/
|
|
119
|
+
function enqueue(write: () => void | Promise<void>): void {
|
|
120
|
+
writes = writes.then(write).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function persist(presetName: string | undefined, overrides: Partial<Knobs>): void {
|
|
124
|
+
const serialized = Object.keys(overrides).length > 0 ? JSON.stringify(overrides) : undefined;
|
|
125
|
+
enqueue(async () => {
|
|
126
|
+
await (presetName
|
|
127
|
+
? storage.setItem(cookiePreset, presetName)
|
|
128
|
+
: storage.removeItem(cookiePreset));
|
|
129
|
+
await (serialized
|
|
130
|
+
? storage.setItem(storedOverridesKey, serialized)
|
|
131
|
+
: storage.removeItem(storedOverridesKey));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Switch the stored preset; its overrides reset unless given. */
|
|
136
|
+
export function setStoredPreset(
|
|
137
|
+
presetName: string | undefined,
|
|
138
|
+
overrides: Partial<Knobs> = {},
|
|
139
|
+
): void {
|
|
140
|
+
if (isServer()) return;
|
|
141
|
+
written = true;
|
|
142
|
+
const name = presetName || undefined;
|
|
143
|
+
persist(name, overrides);
|
|
144
|
+
emit({ presetName: name, overrides, hydrated: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Replace the stored knob overrides of the current preset. */
|
|
148
|
+
export function setStoredOverrides(overrides: Partial<Knobs>): void {
|
|
149
|
+
if (isServer()) return;
|
|
150
|
+
written = true;
|
|
151
|
+
persist(state.presetName, overrides);
|
|
152
|
+
emit({ ...state, overrides, hydrated: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const presetChoicesKey = "mp.preset.by-user";
|
|
156
|
+
|
|
157
|
+
export interface PresetChoices {
|
|
158
|
+
/** The signed-out choice made on this device. */
|
|
159
|
+
device?: string;
|
|
160
|
+
/** Each signed-in user's choice, by user id. */
|
|
161
|
+
users: Record<string, string>;
|
|
162
|
+
/** The user whose choice is live now; undefined while the device's is. */
|
|
163
|
+
owner?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let choices: PresetChoices = { users: {} };
|
|
167
|
+
let choicesLoad: Promise<PresetChoices> | undefined;
|
|
168
|
+
let choicesLoaded = false;
|
|
169
|
+
let choicesGeneration = 0;
|
|
170
|
+
let deviceWritten = false;
|
|
171
|
+
let ownerWritten = false;
|
|
172
|
+
const usersWritten = new Set<string>();
|
|
173
|
+
|
|
174
|
+
function parseChoices(raw: unknown): PresetChoices {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = typeof raw === "string" ? JSON.parse(raw) : undefined;
|
|
177
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { users: {} };
|
|
178
|
+
const users = parsed.users;
|
|
179
|
+
return {
|
|
180
|
+
users:
|
|
181
|
+
users && typeof users === "object" && !Array.isArray(users)
|
|
182
|
+
? (Object.fromEntries(
|
|
183
|
+
Object.entries(users).filter(([, value]) => typeof value === "string"),
|
|
184
|
+
) as Record<string, string>)
|
|
185
|
+
: {},
|
|
186
|
+
...(typeof parsed.device === "string" && { device: parsed.device }),
|
|
187
|
+
...(typeof parsed.owner === "string" && { owner: parsed.owner }),
|
|
188
|
+
};
|
|
189
|
+
} catch {
|
|
190
|
+
return { users: {} };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function persistChoices(): void {
|
|
195
|
+
if (!choicesLoaded || isServer()) return;
|
|
196
|
+
const serialized = JSON.stringify(choices);
|
|
197
|
+
enqueue(() => storage.setItem(presetChoicesKey, serialized));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function adoptChoices(raw: unknown, generation: number): PresetChoices {
|
|
201
|
+
if (generation !== choicesGeneration) return choices;
|
|
202
|
+
const loaded = parseChoices(raw);
|
|
203
|
+
choices = {
|
|
204
|
+
...loaded,
|
|
205
|
+
users: {
|
|
206
|
+
...loaded.users,
|
|
207
|
+
...Object.fromEntries([...usersWritten].map((id) => [id, choices.users[id]])),
|
|
208
|
+
},
|
|
209
|
+
...(deviceWritten && { device: choices.device }),
|
|
210
|
+
...(ownerWritten && { owner: choices.owner }),
|
|
211
|
+
};
|
|
212
|
+
choicesLoaded = true;
|
|
213
|
+
if (deviceWritten || ownerWritten || usersWritten.size > 0) persistChoices();
|
|
214
|
+
return choices;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function loadPresetChoices(): Promise<PresetChoices> {
|
|
218
|
+
if (isServer()) return Promise.resolve(choices);
|
|
219
|
+
if (choicesLoaded) return Promise.resolve(choices);
|
|
220
|
+
if (choicesLoad) return choicesLoad;
|
|
221
|
+
const generation = choicesGeneration;
|
|
222
|
+
let raw: unknown;
|
|
223
|
+
try {
|
|
224
|
+
raw = storage.getItem(presetChoicesKey);
|
|
225
|
+
} catch {
|
|
226
|
+
raw = undefined;
|
|
227
|
+
}
|
|
228
|
+
if (!isThenable(raw)) {
|
|
229
|
+
choicesLoad = Promise.resolve(adoptChoices(raw, generation));
|
|
230
|
+
return choicesLoad;
|
|
231
|
+
}
|
|
232
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
233
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
234
|
+
timer = setTimeout(() => resolve(undefined), HYDRATE_TIMEOUT_MS);
|
|
235
|
+
});
|
|
236
|
+
choicesLoad = Promise.race([raw, timeout])
|
|
237
|
+
.catch(() => undefined)
|
|
238
|
+
.then((value) => adoptChoices(value, generation))
|
|
239
|
+
.finally(() => clearTimeout(timer));
|
|
240
|
+
return choicesLoad;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getPresetChoices(): PresetChoices {
|
|
244
|
+
return choices;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function recordPresetChoice(name: string, userId?: string | null): PresetChoices {
|
|
248
|
+
void loadPresetChoices();
|
|
249
|
+
ownerWritten = true;
|
|
250
|
+
if (typeof userId === "string") {
|
|
251
|
+
usersWritten.add(userId);
|
|
252
|
+
choices = { ...choices, users: { ...choices.users, [userId]: name }, owner: userId };
|
|
253
|
+
} else {
|
|
254
|
+
deviceWritten = true;
|
|
255
|
+
choices = { ...choices, device: name, owner: undefined };
|
|
256
|
+
}
|
|
257
|
+
persistChoices();
|
|
258
|
+
return choices;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function setPresetOwner(userId?: string | null): PresetChoices {
|
|
262
|
+
void loadPresetChoices();
|
|
263
|
+
ownerWritten = true;
|
|
264
|
+
choices = { ...choices, owner: userId ?? undefined };
|
|
265
|
+
persistChoices();
|
|
266
|
+
return choices;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function presetChoiceFor(userId?: string | null): string | undefined {
|
|
270
|
+
return typeof userId === "string"
|
|
271
|
+
? Object.prototype.hasOwnProperty.call(choices.users, userId)
|
|
272
|
+
? choices.users[userId]
|
|
273
|
+
: undefined
|
|
274
|
+
: choices.device;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @internal */
|
|
278
|
+
export function resetPresetChoicesForTests(): void {
|
|
279
|
+
choicesGeneration += 1;
|
|
280
|
+
choices = { users: {} };
|
|
281
|
+
choicesLoad = undefined;
|
|
282
|
+
choicesLoaded = false;
|
|
283
|
+
deviceWritten = false;
|
|
284
|
+
ownerWritten = false;
|
|
285
|
+
usersWritten.clear();
|
|
286
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { storage } from "@multiplatform.one/store";
|
|
4
|
+
import {
|
|
5
|
+
getPresetChoices,
|
|
6
|
+
loadPresetChoices,
|
|
7
|
+
recordPresetChoice,
|
|
8
|
+
resetPresetChoicesForTests,
|
|
9
|
+
setPresetOwner,
|
|
10
|
+
} from "./presetStore";
|
|
11
|
+
import {
|
|
12
|
+
type PresetSessionStatus,
|
|
13
|
+
useDevicePresetRestore,
|
|
14
|
+
usePresetUserSync,
|
|
15
|
+
} from "./presetUserSync";
|
|
16
|
+
|
|
17
|
+
const { items, applyPreset } = vi.hoisted(() => ({
|
|
18
|
+
items: new Map<string, string>(),
|
|
19
|
+
applyPreset: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("./usePresetSwitch", () => ({
|
|
22
|
+
usePresetSwitch: () => ({ presetName: "default", applyPreset }),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("@multiplatform.one/store", () => ({
|
|
25
|
+
storage: {
|
|
26
|
+
getItem: vi.fn((key: string) => items.get(key) ?? null),
|
|
27
|
+
setItem: (key: string, value: string) => {
|
|
28
|
+
items.set(key, value);
|
|
29
|
+
},
|
|
30
|
+
removeItem: (key: string) => {
|
|
31
|
+
items.delete(key);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
type Session = { userId?: string | null; status: PresetSessionStatus };
|
|
37
|
+
async function flush() {
|
|
38
|
+
await act(async () => {
|
|
39
|
+
await loadPresetChoices();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
items.clear();
|
|
45
|
+
applyPreset.mockReset();
|
|
46
|
+
vi.mocked(storage.getItem)
|
|
47
|
+
.mockReset()
|
|
48
|
+
.mockImplementation((key) => items.get(key) ?? null);
|
|
49
|
+
resetPresetChoicesForTests();
|
|
50
|
+
});
|
|
51
|
+
afterEach(cleanup);
|
|
52
|
+
|
|
53
|
+
describe("preset session sync", () => {
|
|
54
|
+
it("restores the device on sign-out and remembers each user's choice", async () => {
|
|
55
|
+
const { rerender } = renderHook((session: Session) => usePresetUserSync(session), {
|
|
56
|
+
initialProps: { userId: "a", status: "authenticated" } as Session,
|
|
57
|
+
});
|
|
58
|
+
await flush();
|
|
59
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
60
|
+
recordPresetChoice("dense", "a");
|
|
61
|
+
rerender({ status: "unauthenticated" });
|
|
62
|
+
await flush();
|
|
63
|
+
expect(applyPreset.mock.calls).toEqual([["default"]]);
|
|
64
|
+
expect(getPresetChoices().owner).toBeUndefined();
|
|
65
|
+
rerender({ userId: "b", status: "authenticated" });
|
|
66
|
+
await flush();
|
|
67
|
+
expect(applyPreset.mock.calls).toEqual([["default"]]);
|
|
68
|
+
expect(getPresetChoices().owner).toBe("b");
|
|
69
|
+
rerender({ userId: "a", status: "authenticated" });
|
|
70
|
+
await flush();
|
|
71
|
+
expect(applyPreset.mock.calls).toEqual([["default"], ["dense"]]);
|
|
72
|
+
expect(getPresetChoices().users).toEqual({ a: "dense" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("restores a signed-out device choice instead of default", async () => {
|
|
76
|
+
recordPresetChoice("roomy");
|
|
77
|
+
recordPresetChoice("dense", "a");
|
|
78
|
+
renderHook(() => usePresetUserSync({ status: "unauthenticated" }));
|
|
79
|
+
await flush();
|
|
80
|
+
expect(applyPreset).toHaveBeenCalledExactlyOnceWith("roomy");
|
|
81
|
+
expect(getPresetChoices()).toEqual({
|
|
82
|
+
device: "roomy",
|
|
83
|
+
users: { a: "dense" },
|
|
84
|
+
owner: undefined,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does nothing while loading, even with a user id", async () => {
|
|
89
|
+
recordPresetChoice("dense", "a");
|
|
90
|
+
renderHook(() => usePresetUserSync({ status: "loading", userId: "b" }));
|
|
91
|
+
await flush();
|
|
92
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
93
|
+
expect(getPresetChoices().owner).toBe("a");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does nothing when the owner already matches on reload", async () => {
|
|
97
|
+
recordPresetChoice("dense", "a");
|
|
98
|
+
resetPresetChoicesForTests();
|
|
99
|
+
renderHook(() => usePresetUserSync({ status: "authenticated", userId: "a" }));
|
|
100
|
+
await flush();
|
|
101
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("leaves an already signed-out device alone", async () => {
|
|
105
|
+
recordPresetChoice("roomy");
|
|
106
|
+
renderHook(() => usePresetUserSync({ status: "unauthenticated" }));
|
|
107
|
+
await flush();
|
|
108
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does nothing for an authenticated session without a user id", async () => {
|
|
112
|
+
recordPresetChoice("dense", "a");
|
|
113
|
+
renderHook(() => usePresetUserSync({ status: "authenticated" }));
|
|
114
|
+
await flush();
|
|
115
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
116
|
+
expect(getPresetChoices().owner).toBe("a");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("ignores a pending hydration when the session changes to loading", async () => {
|
|
120
|
+
let finish!: (value: string) => void;
|
|
121
|
+
vi.mocked(storage.getItem).mockReturnValue(
|
|
122
|
+
new Promise((resolve) => {
|
|
123
|
+
finish = resolve;
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
const { rerender } = renderHook((session: Session) => usePresetUserSync(session), {
|
|
127
|
+
initialProps: { userId: "a", status: "authenticated" } as Session,
|
|
128
|
+
});
|
|
129
|
+
rerender({ status: "loading" });
|
|
130
|
+
finish(JSON.stringify({ users: { a: "dense" } }));
|
|
131
|
+
await flush();
|
|
132
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
133
|
+
expect(getPresetChoices().owner).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("cancels pending sync on unmount", async () => {
|
|
137
|
+
let finish!: (value: string) => void;
|
|
138
|
+
vi.mocked(storage.getItem).mockReturnValue(
|
|
139
|
+
new Promise((resolve) => {
|
|
140
|
+
finish = resolve;
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
const { unmount } = renderHook(() =>
|
|
144
|
+
usePresetUserSync({ status: "authenticated", userId: "a" }),
|
|
145
|
+
);
|
|
146
|
+
unmount();
|
|
147
|
+
finish(JSON.stringify({ users: { a: "dense" } }));
|
|
148
|
+
await flush();
|
|
149
|
+
expect(applyPreset).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns a stable sign-out callback that reads the current device choice", () => {
|
|
153
|
+
const { result, rerender } = renderHook(() => useDevicePresetRestore());
|
|
154
|
+
const restore = result.current;
|
|
155
|
+
recordPresetChoice("roomy");
|
|
156
|
+
recordPresetChoice("dense", "a");
|
|
157
|
+
rerender();
|
|
158
|
+
expect(result.current).toBe(restore);
|
|
159
|
+
act(restore);
|
|
160
|
+
expect(applyPreset).toHaveBeenLastCalledWith("roomy");
|
|
161
|
+
expect(getPresetChoices().owner).toBeUndefined();
|
|
162
|
+
resetPresetChoicesForTests();
|
|
163
|
+
items.clear();
|
|
164
|
+
setPresetOwner("b");
|
|
165
|
+
act(restore);
|
|
166
|
+
expect(applyPreset).toHaveBeenLastCalledWith("default");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getPresetChoices,
|
|
4
|
+
loadPresetChoices,
|
|
5
|
+
presetChoiceFor,
|
|
6
|
+
setPresetOwner,
|
|
7
|
+
} from "./presetStore";
|
|
8
|
+
import { usePresetSwitch } from "./usePresetSwitch";
|
|
9
|
+
|
|
10
|
+
export type PresetSessionStatus = "loading" | "authenticated" | "unauthenticated";
|
|
11
|
+
|
|
12
|
+
export function usePresetUserSync(session: {
|
|
13
|
+
userId?: string | null;
|
|
14
|
+
status: PresetSessionStatus;
|
|
15
|
+
}): void {
|
|
16
|
+
const { userId, status } = session;
|
|
17
|
+
const { applyPreset } = usePresetSwitch();
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (status === "loading") return;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
void loadPresetChoices().then(() => {
|
|
22
|
+
if (cancelled) return;
|
|
23
|
+
const choices = getPresetChoices();
|
|
24
|
+
if (status === "authenticated" && userId) {
|
|
25
|
+
if (choices.owner === userId) return;
|
|
26
|
+
const name = presetChoiceFor(userId);
|
|
27
|
+
setPresetOwner(userId);
|
|
28
|
+
if (name !== undefined) applyPreset(name);
|
|
29
|
+
} else if (status === "unauthenticated") {
|
|
30
|
+
if (choices.owner === undefined) return;
|
|
31
|
+
setPresetOwner(undefined);
|
|
32
|
+
applyPreset(choices.device ?? "default");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
};
|
|
38
|
+
}, [userId, status, applyPreset]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useDevicePresetRestore(): () => void {
|
|
42
|
+
const { applyPreset } = usePresetSwitch();
|
|
43
|
+
return useCallback(() => {
|
|
44
|
+
setPresetOwner(undefined);
|
|
45
|
+
applyPreset(getPresetChoices().device ?? "default");
|
|
46
|
+
}, [applyPreset]);
|
|
47
|
+
}
|