vanilla-jui 1.5.9 → 1.5.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-jui",
3
- "version": "1.5.9",
3
+ "version": "1.5.11",
4
4
  "private": false,
5
5
  "description": "A lightweight, reactive, and fully customizable vanilla JS UI library.",
6
6
  "keywords": [
@@ -1,4 +1,4 @@
1
- import { createDeepStore, flushSync, jsx } from 'vanilla-signal';
1
+ import { createDeepStore, createMemo, flushSync, jsx } from 'vanilla-signal';
2
2
 
3
3
  import {
4
4
  type FunctionalComponent,
@@ -101,6 +101,9 @@ export interface FlowState extends Record<string, unknown> {
101
101
 
102
102
  export interface FlowGoToOptions {
103
103
  direction?: FlowDirection;
104
+ }
105
+
106
+ interface InternalFlowGoToOptions extends FlowGoToOptions {
104
107
  internal?: boolean;
105
108
  }
106
109
 
@@ -312,6 +315,8 @@ interface FlowError extends Error {
312
315
  code?: string;
313
316
  }
314
317
 
318
+ type ActionToken = number | null;
319
+
315
320
  const DEFAULT_CLASS_NAMES: FlowClassNames = {
316
321
  root: 'j-flow',
317
322
  header: 'flow-header',
@@ -341,6 +346,27 @@ function clonePlainObject(value: unknown): FlowData {
341
346
  return isPlainObject(value) ? { ...(value as FlowData) } : {};
342
347
  }
343
348
 
349
+ function cloneStepData(
350
+ value: Record<string, FlowData>
351
+ ): Record<string, FlowData> {
352
+ const result: Record<string, FlowData> = {};
353
+ for (const key of Object.keys(value)) {
354
+ result[key] = clonePlainObject(value[key]);
355
+ }
356
+ return result;
357
+ }
358
+
359
+ function cloneSnapshot(value: FlowSnapshot): FlowSnapshot {
360
+ return {
361
+ ...value,
362
+ history: [...value.history],
363
+ data: clonePlainObject(value.data),
364
+ stepData: cloneStepData(value.stepData),
365
+ currentData: clonePlainObject(value.currentData),
366
+ currentStep: value.currentStep ? { ...value.currentStep } : null,
367
+ };
368
+ }
369
+
344
370
  function cloneSteps(steps: unknown): FlowStep[] {
345
371
  if (!Array.isArray(steps)) return [];
346
372
  return steps.map((step) => {
@@ -378,7 +404,9 @@ function normalizeStepResult(
378
404
  if (typeof result === 'string') return { id: result };
379
405
  if (!isPlainObject(result)) return { id: fallbackId };
380
406
  const source = result as { id?: unknown; data?: unknown };
381
- if (typeof source.id !== 'string') return { id: fallbackId };
407
+ if (typeof source.id !== 'string') {
408
+ return { id: fallbackId, data: result as FlowPayload };
409
+ }
382
410
  return {
383
411
  id: source.id,
384
412
  data:
@@ -526,7 +554,7 @@ export function createFlow(input: FlowProps = {}): Flow {
526
554
  direction: null,
527
555
  history: [initialStepId],
528
556
  data: clonePlainObject(initialGlobal),
529
- stepData: clonePlainObject(initialStepData) as Record<string, FlowData>,
557
+ stepData: cloneStepData(initialStepData),
530
558
  loading: false,
531
559
  error: null,
532
560
  busyAction: null,
@@ -541,6 +569,9 @@ export function createFlow(input: FlowProps = {}): Flow {
541
569
  };
542
570
  const subscribers = new Set<FlowSubscriber>();
543
571
  const cleanupTasks = new Set<FlowCleanup>();
572
+ const cancelledActions = new Set<number>();
573
+ let actionToken = 0;
574
+ let activeActionToken: number | null = null;
544
575
  let ui: FunctionalComponent<
545
576
  Record<string, unknown>,
546
577
  FlowState,
@@ -554,9 +585,6 @@ export function createFlow(input: FlowProps = {}): Flow {
554
585
  throw new Error(`Flow.${method}: instance has been destroyed.`);
555
586
  }
556
587
  };
557
- const currentStep = (): FlowStep => steps[state.currentIndex];
558
- const getStepData = (stepId: string): FlowData =>
559
- clonePlainObject(state.stepData[stepId]);
560
588
  const publicStep = (
561
589
  step: FlowStep | null | undefined
562
590
  ): PublicFlowStep | null => {
@@ -572,7 +600,23 @@ export function createFlow(input: FlowProps = {}): Flow {
572
600
  } = step;
573
601
  return result;
574
602
  };
575
- const snapshot = (): FlowSnapshot => ({
603
+ const currentStepMemo = createMemo(() => steps[state.currentIndex]);
604
+ const currentDataMemo = createMemo(() => {
605
+ const version = state.version;
606
+ return version >= 0
607
+ ? clonePlainObject(state.stepData[state.currentId])
608
+ : {};
609
+ });
610
+ const publicCurrentStepMemo = createMemo(() => publicStep(currentStepMemo()));
611
+ const publicStepsMemo = createMemo(() => steps.map(publicStep));
612
+ const canBackMemo = createMemo(
613
+ () => state.currentIndex > 0 && !state.loading
614
+ );
615
+ const canNextMemo = createMemo(
616
+ () => state.currentIndex < steps.length - 1 && !state.loading
617
+ );
618
+ const isLastMemo = createMemo(() => state.currentIndex === steps.length - 1);
619
+ const snapshotMemo = createMemo<FlowSnapshot>(() => ({
576
620
  id: state.id,
577
621
  currentId: state.currentId,
578
622
  currentIndex: state.currentIndex,
@@ -581,16 +625,20 @@ export function createFlow(input: FlowProps = {}): Flow {
581
625
  direction: state.direction,
582
626
  history: [...state.history],
583
627
  data: clonePlainObject(state.data),
584
- stepData: clonePlainObject(state.stepData) as Record<string, FlowData>,
585
- currentData: getStepData(state.currentId),
586
- currentStep: publicStep(currentStep()),
587
- canBack: state.currentIndex > 0 && !state.loading,
588
- canNext: state.currentIndex < steps.length - 1 && !state.loading,
589
- isLast: state.currentIndex === steps.length - 1,
628
+ stepData: cloneStepData(state.stepData),
629
+ currentData: currentDataMemo(),
630
+ currentStep: publicCurrentStepMemo(),
631
+ canBack: canBackMemo(),
632
+ canNext: canNextMemo(),
633
+ isLast: isLastMemo(),
590
634
  loading: state.loading,
591
635
  busyAction: state.busyAction,
592
636
  error: state.error,
593
- });
637
+ }));
638
+ const currentStep = (): FlowStep => currentStepMemo();
639
+ const getStepData = (stepId: string): FlowData =>
640
+ clonePlainObject(state.stepData[stepId]);
641
+ const snapshot = (): FlowSnapshot => cloneSnapshot(snapshotMemo());
594
642
  const emitChange = (previous: FlowSnapshot | null = null): void => {
595
643
  const next = snapshot();
596
644
  for (const subscriber of subscribers) subscriber(next, flow, previous);
@@ -608,7 +656,10 @@ export function createFlow(input: FlowProps = {}): Flow {
608
656
  assertActive('setStepData');
609
657
  validateParam('stepId', stepId, { type: 'string' }, 'Flow.setStepData');
610
658
  validateParam('data', data, FLOW_PAYLOAD_RULE, 'Flow.setStepData');
611
- if (!data || !stepMap.has(stepId)) return flow;
659
+ if (!stepMap.has(stepId)) {
660
+ throw new Error(`Flow.setStepData: step "${stepId}" does not exist.`);
661
+ }
662
+ if (!data) return flow;
612
663
  flushSync(() => {
613
664
  state.stepData[stepId] = {
614
665
  ...clonePlainObject(state.stepData[stepId]),
@@ -647,15 +698,32 @@ export function createFlow(input: FlowProps = {}): Flow {
647
698
  error.code = 'FLOW_ABORTED';
648
699
  return error;
649
700
  };
701
+ const isAbortError = (error: unknown): error is FlowError =>
702
+ error instanceof Error && (error as FlowError).code === 'FLOW_ABORTED';
703
+ const assertActionCurrent = (token: ActionToken): void => {
704
+ if (runtime.destroyed) throw createAbortError();
705
+ if (token != null && cancelledActions.has(token)) {
706
+ throw createAbortError();
707
+ }
708
+ };
709
+ const cancelActiveAction = (): void => {
710
+ const token = activeActionToken;
711
+ if (token != null) cancelledActions.add(token);
712
+ runtime.actionController?.abort();
713
+ runtime.actionController = null;
714
+ runtime.activeAction = null;
715
+ activeActionToken = null;
716
+ };
650
717
  const callHook = async <TResult>(
651
718
  hook: ((...args: never[]) => TResult | Promise<TResult>) | null | undefined,
652
- args: unknown[]
719
+ args: unknown[],
720
+ token: ActionToken = activeActionToken
653
721
  ): Promise<TResult | undefined> => {
654
722
  if (typeof hook !== 'function') return undefined;
655
723
  const result = await (
656
724
  hook as (...values: unknown[]) => TResult | Promise<TResult>
657
725
  )(...args);
658
- if (runtime.destroyed) throw createAbortError();
726
+ assertActionCurrent(token);
659
727
  return result;
660
728
  };
661
729
  const createContext = (
@@ -717,26 +785,33 @@ export function createFlow(input: FlowProps = {}): Flow {
717
785
  };
718
786
  const runAction = async (
719
787
  action: FlowAction,
720
- task: () => Promise<FlowSnapshot | null>,
788
+ task: (token: ActionToken) => Promise<FlowSnapshot | null>,
721
789
  internal = false
722
790
  ): Promise<FlowSnapshot | null> => {
723
791
  const outer = !internal && !state.loading;
792
+ const token = outer ? ++actionToken : activeActionToken;
724
793
  if (outer) {
725
794
  runtime.actionController = new AbortController();
726
795
  runtime.activeAction = action;
796
+ activeActionToken = token;
727
797
  setLoading(true, action);
728
798
  }
729
799
  try {
730
- return await task();
800
+ assertActionCurrent(token);
801
+ const result = await task(token);
802
+ assertActionCurrent(token);
803
+ return result;
731
804
  } catch (error) {
732
- if (runtime.destroyed) return null;
805
+ if (runtime.destroyed || isAbortError(error)) return null;
733
806
  if (state.error !== error) handleError(error);
734
807
  throw error;
735
808
  } finally {
736
- if (outer && !runtime.destroyed) {
809
+ if (token != null) cancelledActions.delete(token);
810
+ if (outer && !runtime.destroyed && activeActionToken === token) {
737
811
  setLoading(false, null);
738
812
  runtime.activeAction = null;
739
813
  runtime.actionController = null;
814
+ activeActionToken = null;
740
815
  }
741
816
  }
742
817
  };
@@ -748,7 +823,7 @@ export function createFlow(input: FlowProps = {}): Flow {
748
823
  direction: state.direction,
749
824
  history: [...state.history],
750
825
  data: clonePlainObject(state.data),
751
- stepData: clonePlainObject(state.stepData) as Record<string, FlowData>,
826
+ stepData: cloneStepData(state.stepData),
752
827
  loading: state.loading,
753
828
  busyAction: state.busyAction,
754
829
  error: state.error,
@@ -764,7 +839,7 @@ export function createFlow(input: FlowProps = {}): Flow {
764
839
  state.history.splice(0, state.history.length, ...captured.history);
765
840
  replaceObject(state.data, captured.data);
766
841
  replaceObject(state.stepData, captured.stepData);
767
- state.loading = true;
842
+ state.loading = captured.loading;
768
843
  state.busyAction = captured.busyAction;
769
844
  state.error = captured.error;
770
845
  state.version = captured.version + 1;
@@ -784,31 +859,39 @@ export function createFlow(input: FlowProps = {}): Flow {
784
859
  toStep: FlowStep,
785
860
  direction: FlowDirection,
786
861
  payload: FlowPayload,
787
- fromStep: FlowStep
862
+ fromStep: FlowStep,
863
+ token: ActionToken
788
864
  ): Promise<void> => {
789
865
  const previous = snapshot();
790
866
  const rollback = captureState();
791
867
  try {
792
868
  if (payload) setStepData(fromStep.id, payload, { silent: true });
793
869
  if (fromStep.canLeave) {
794
- const allowed = await callHook(fromStep.canLeave, [
795
- createContext({ direction, targetId: toStep.id }),
796
- ]);
870
+ const allowed = await callHook(
871
+ fromStep.canLeave,
872
+ [createContext({ direction, targetId: toStep.id })],
873
+ token
874
+ );
797
875
  if (allowed === false) {
798
876
  throw new Error(`Flow: step "${fromStep.id}" blocked leaving.`);
799
877
  }
800
878
  }
801
879
  if (toStep.canEnter) {
802
- const allowed = await callHook(toStep.canEnter, [
803
- createContext({ direction, fromId: fromStep.id, step: toStep }),
804
- ]);
880
+ const allowed = await callHook(
881
+ toStep.canEnter,
882
+ [createContext({ direction, fromId: fromStep.id, step: toStep })],
883
+ token
884
+ );
805
885
  if (allowed === false) {
806
886
  throw new Error(`Flow: step "${toStep.id}" blocked entering.`);
807
887
  }
808
888
  }
809
- await callHook(fromStep.onLeave, [
810
- createContext({ direction, step: fromStep, targetId: toStep.id }),
811
- ]);
889
+ await callHook(
890
+ fromStep.onLeave,
891
+ [createContext({ direction, step: fromStep, targetId: toStep.id })],
892
+ token
893
+ );
894
+ assertActionCurrent(token);
812
895
  const previousId = state.currentId;
813
896
  const previousIndex = state.currentIndex;
814
897
  flushSync(() => {
@@ -822,12 +905,16 @@ export function createFlow(input: FlowProps = {}): Flow {
822
905
  state.error = null;
823
906
  state.version += 1;
824
907
  });
825
- await callHook(toStep.onEnter, [
826
- createContext({ direction, fromId: previousId, step: toStep }),
827
- ]);
908
+ await callHook(
909
+ toStep.onEnter,
910
+ [createContext({ direction, fromId: previousId, step: toStep })],
911
+ token
912
+ );
913
+ assertActionCurrent(token);
828
914
  emitChange(previous);
829
915
  } catch (error) {
830
916
  if (runtime.destroyed) throw error;
917
+ if (isAbortError(error)) throw error;
831
918
  if (props.rollbackOnError) restoreState(rollback);
832
919
  handleError(error, previous);
833
920
  throw error;
@@ -837,17 +924,39 @@ export function createFlow(input: FlowProps = {}): Flow {
837
924
  type: 'next' | 'back',
838
925
  step: FlowStep,
839
926
  payload: FlowPayload,
840
- fallbackId: string
927
+ fallbackId: string,
928
+ token: ActionToken
841
929
  ): Promise<FlowStepResult | FlowPayload | void> => {
842
930
  const stepHook = type === 'back' ? step.onBack : step.onNext;
843
931
  const globalHook = type === 'back' ? props.onBack : props.onNext;
844
932
  const context = createContext({ payload, targetId: fallbackId });
845
- const stepResult = await callHook(stepHook, [context]);
933
+ const stepResult = await callHook(stepHook, [context], token);
846
934
  if (stepResult != null) return stepResult;
847
- const globalResult = await callHook(globalHook, [context]);
935
+ const globalResult = await callHook(globalHook, [context], token);
848
936
  return globalResult ?? fallbackId;
849
937
  };
850
938
 
939
+ const goToInternal = async (
940
+ target: FlowTarget,
941
+ payload: FlowPayload,
942
+ options: InternalFlowGoToOptions = {}
943
+ ): Promise<FlowSnapshot | null> =>
944
+ runAction(
945
+ 'goTo',
946
+ async (token) => {
947
+ const index = resolveStepIndex(target);
948
+ const to = steps[index];
949
+ const from = currentStep();
950
+ if (to.id === state.currentId) {
951
+ if (payload) setStepData(state.currentId, payload);
952
+ return snapshot();
953
+ }
954
+ await transitionTo(to, options.direction || 'go', payload, from, token);
955
+ return snapshot();
956
+ },
957
+ !!options.internal
958
+ );
959
+
851
960
  const createRenderContext = (
852
961
  fallback: () => RenderableContent<FlowRenderContext>
853
962
  ): FlowRenderContext => {
@@ -856,7 +965,7 @@ export function createFlow(input: FlowProps = {}): Flow {
856
965
  flow,
857
966
  snapshot: current,
858
967
  state,
859
- steps: steps.map(publicStep),
968
+ steps: publicStepsMemo(),
860
969
  currentStep: current.currentStep,
861
970
  currentData: current.currentData,
862
971
  data: current.data,
@@ -1044,16 +1153,16 @@ export function createFlow(input: FlowProps = {}): Flow {
1044
1153
  return currentStep();
1045
1154
  },
1046
1155
  get currentData() {
1047
- return getStepData(state.currentId);
1156
+ return clonePlainObject(currentDataMemo());
1048
1157
  },
1049
1158
  get canBack() {
1050
- return state.currentIndex > 0 && !state.loading;
1159
+ return canBackMemo();
1051
1160
  },
1052
1161
  get canNext() {
1053
- return state.currentIndex < steps.length - 1 && !state.loading;
1162
+ return canNextMemo();
1054
1163
  },
1055
1164
  get isLast() {
1056
- return state.currentIndex === steps.length - 1;
1165
+ return isLastMemo();
1057
1166
  },
1058
1167
  build() {
1059
1168
  assertActive('build');
@@ -1093,17 +1202,23 @@ export function createFlow(input: FlowProps = {}): Flow {
1093
1202
  validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.next');
1094
1203
  const busy = handleBusy('next');
1095
1204
  if (busy) return busy;
1096
- return runAction('next', async () => {
1205
+ return runAction('next', async (token) => {
1097
1206
  if (flow.isLast) {
1098
1207
  if (payload) setStepData(state.currentId, payload);
1099
- await callHook(props.onFinish, [snapshot(), flow]);
1208
+ await callHook(props.onFinish, [snapshot(), flow], token);
1100
1209
  return snapshot();
1101
1210
  }
1102
1211
  const from = currentStep();
1103
1212
  const fallback = steps[state.currentIndex + 1]?.id;
1104
- const result = await runMoveHook('next', from, payload, fallback);
1213
+ const result = await runMoveHook(
1214
+ 'next',
1215
+ from,
1216
+ payload,
1217
+ fallback,
1218
+ token
1219
+ );
1105
1220
  const target = normalizeStepResult(result, fallback);
1106
- return flow.goTo(target.id, target.data ?? payload, {
1221
+ return goToInternal(target.id, target.data ?? payload, {
1107
1222
  direction: 'next',
1108
1223
  internal: true,
1109
1224
  });
@@ -1115,12 +1230,18 @@ export function createFlow(input: FlowProps = {}): Flow {
1115
1230
  const busy = handleBusy('back');
1116
1231
  if (busy) return busy;
1117
1232
  if (!flow.canBack) return snapshot();
1118
- return runAction('back', async () => {
1233
+ return runAction('back', async (token) => {
1119
1234
  const from = currentStep();
1120
1235
  const fallback = steps[state.currentIndex - 1]?.id;
1121
- const result = await runMoveHook('back', from, payload, fallback);
1236
+ const result = await runMoveHook(
1237
+ 'back',
1238
+ from,
1239
+ payload,
1240
+ fallback,
1241
+ token
1242
+ );
1122
1243
  const target = normalizeStepResult(result, fallback);
1123
- return flow.goTo(target.id, target.data ?? payload, {
1244
+ return goToInternal(target.id, target.data ?? payload, {
1124
1245
  direction: 'back',
1125
1246
  internal: true,
1126
1247
  });
@@ -1129,29 +1250,17 @@ export function createFlow(input: FlowProps = {}): Flow {
1129
1250
  async goTo(target, payload = null, options = {}) {
1130
1251
  assertActive('goTo');
1131
1252
  validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.goTo');
1132
- const busy = options.internal ? null : handleBusy('goTo');
1253
+ const busy = handleBusy('goTo');
1133
1254
  if (busy) return busy;
1134
- return runAction(
1135
- 'goTo',
1136
- async () => {
1137
- const index = resolveStepIndex(target);
1138
- const to = steps[index];
1139
- const from = currentStep();
1140
- if (to.id === state.currentId) {
1141
- if (payload) setStepData(state.currentId, payload);
1142
- return snapshot();
1143
- }
1144
- await transitionTo(to, options.direction || 'go', payload, from);
1145
- return snapshot();
1146
- },
1147
- !!options.internal
1148
- );
1255
+ return goToInternal(target, payload, { direction: options.direction });
1149
1256
  },
1150
1257
  setData,
1151
1258
  setStepData,
1152
1259
  getStepData,
1153
1260
  reset() {
1154
1261
  assertActive('reset');
1262
+ const previous = snapshot();
1263
+ cancelActiveAction();
1155
1264
  flushSync(() => {
1156
1265
  state.currentId = initialStepId;
1157
1266
  state.currentIndex = stepMap.get(initialStepId) ?? 0;
@@ -1160,16 +1269,13 @@ export function createFlow(input: FlowProps = {}): Flow {
1160
1269
  state.direction = null;
1161
1270
  state.history.splice(0, state.history.length, initialStepId);
1162
1271
  replaceObject(state.data, clonePlainObject(initialGlobal));
1163
- replaceObject(
1164
- state.stepData,
1165
- clonePlainObject(initialStepData) as Record<string, FlowData>
1166
- );
1272
+ replaceObject(state.stepData, cloneStepData(initialStepData));
1167
1273
  state.loading = false;
1168
1274
  state.busyAction = null;
1169
1275
  state.error = null;
1170
1276
  state.version += 1;
1171
1277
  });
1172
- emitChange();
1278
+ emitChange(previous);
1173
1279
  return flow;
1174
1280
  },
1175
1281
  async finish(payload = null) {
@@ -1177,18 +1283,16 @@ export function createFlow(input: FlowProps = {}): Flow {
1177
1283
  validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.finish');
1178
1284
  const busy = handleBusy('finish');
1179
1285
  if (busy) return busy;
1180
- return runAction('finish', async () => {
1286
+ return runAction('finish', async (token) => {
1181
1287
  if (payload) setStepData(state.currentId, payload);
1182
- await callHook(props.onFinish, [snapshot(), flow]);
1288
+ await callHook(props.onFinish, [snapshot(), flow], token);
1183
1289
  return snapshot();
1184
1290
  });
1185
1291
  },
1186
1292
  destroy() {
1187
1293
  if (runtime.destroyed) return;
1188
1294
  runtime.destroyed = true;
1189
- runtime.actionController?.abort();
1190
- runtime.actionController = null;
1191
- runtime.activeAction = null;
1295
+ cancelActiveAction();
1192
1296
  ui?.destroy();
1193
1297
  ui = null;
1194
1298
  for (const cleanup of cleanupTasks) cleanup();